pthread_create에 구조체 포인터 넘기기 — void 포인터와 이중포인터 정리
NOTE
pthread_create의 마지막 인자(void *)로 구조체 포인터를 넘길 때&를 붙여야 하는 경우와 안 붙여도 되는 경우, 이중포인터일 때의 규칙을 정리한다.
0. 배경
질문 예시를 정리하면 이런 코드다.
struct_temp *ctx;
pthread_create(&tid, NULL, worker, ctx);
void *worker(void *arg)
{
struct_temp *c = (struct_temp *)arg;
/* ... */
}이렇게 쓰면 되는지, &를 붙여야 할 때와 안 붙여도 될 때를 어떻게 구분하는지, 이중포인터일 때는 규칙이 어떻게 되는지가 헷갈리는 지점이다.
여기서
struct_temp는 편의상 표기이며 실제로는struct temp *ctx;(구조체 태그 이름) 또는typedef struct { ... } temp_t; temp_t *ctx;형태일 것이다. 받는 쪽 캐스팅도 실제 타입 이름을 그대로 써야 한다.
1. pthread_create 마지막 인자는 “포인터 값 하나”
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine)(void *), void *arg);arg는 void * 딱 하나다. 여기에 뭘 채워 넣을지는 호출하는 쪽 마음이고, 콜백(start_routine) 안에서 원래 타입으로 다시 캐스팅해서 쓴다. 핵심 규칙은 하나뿐이다.
넘길 때 이미 갖고 있는 값이 포인터냐 아니냐만 본다.
- 이미 포인터(
T *ptr) → 그대로 넘긴다.- 포인터가 아닌 값 자체(
T val) → 주소를 떠서(&val) 넘긴다.
2. 구조체 포인터를 넘기는 정석 패턴
struct_temp *ctx = malloc(sizeof(*ctx));
ctx->fd = fd;
ctx->id = id;
pthread_create(&tid, NULL, worker, ctx); /* ctx는 이미 struct_temp* 이므로 그대로 넘김 */
void *worker(void *arg)
{
struct_temp *ctx = (struct_temp *)arg; /* void* -> struct_temp* 캐스팅으로 원래 타입 복원 */
/* ctx->fd, ctx->id 사용 */
free(ctx); /* 이 스레드가 마지막 사용자라면 여기서 해제 — 소유권을 누가 갖는지 미리 정해둬야 함 */
return NULL;
}ctx는 이미struct_temp *이므로&ctx를 넘기면 안 된다 —&ctx를 넘기면 타입이struct_temp **(이중포인터)가 되어 콜백 쪽 캐스팅과 어긋난다(4번 참고).void*↔ 다른 객체 포인터 타입 간 변환은 C 표준상 암묵적으로도 가능하다(캐스팅 없이 대입해도 컴파일됨). 그런데도 실무에서(struct_temp *)arg처럼 명시적으로 캐스팅하는 이유는 (1) 코드를 읽을 때 의도가 분명해지고, (2) C++로 컴파일할 경우(예: C/C++ 겸용 헤더)void*→ 구체 타입 변환은 명시적 캐스팅이 필수라서, 처음부터 캐스팅을 써두면 이식성이 좋아지기 때문이다.
3. 헷갈리는 지점 — “포인터인데 왜 &를 안 붙이나”
&는 “이 변수의 주소를 달라”는 연산자다. ctx가 이미 struct_temp *(포인터 변수)라면, ctx 자체가 “이미 어떤 구조체의 주소”를 담고 있다. 여기에 &를 또 붙이면 “포인터 변수 ctx 자신이 메모리 어디에 있는지”(포인터의 포인터)를 구하는 게 되어버려, 원하던 “구조체의 주소”와는 전혀 다른 값이 된다.
int x = 5;
int *p = &x; /* p: x의 주소 */
int **pp = &p; /* pp: p 변수 자신의 주소 — x와는 한 단계 더 떨어짐 */같은 원리다.
struct_temp *ctx = malloc(...);
pthread_create(..., ctx); /* O — struct_temp* 그대로 */
pthread_create(..., &ctx); /* X (의도한 값 아님) — struct_temp**가 되어버림 */4. 이중포인터(T**)는 언제, 어떻게 넘기나
원래부터 T ** 타입인 값(포인터 배열 등)을 넘기고 싶을 때는 규칙이 한 단계 밀려서 똑같이 적용된다 — “이미 T **라면 그대로, T * 하나뿐인데 그 주소가 필요하면 &”.
struct_temp **arr = malloc(n * sizeof(*arr)); /* arr: struct_temp** */
pthread_create(&tid, NULL, worker, arr); /* arr 자체가 이미 T** 이므로 그대로 넘김 */
void *worker(void *raw)
{
struct_temp **arr = (struct_temp **)raw; /* 캐스팅도 T**로 맞춤 */
struct_temp *first = arr[0]; /* 또는 *arr */
}“이중포인터의 주소”(T ***)까지 넘기는 경우는 실무에서 거의 없다 — 스레드가 포인터 배열 하나를 통째로 받아 인덱싱하는 상황 정도가 아니면 나올 일이 드물다. 그런 극단적인 경우가 필요해도 규칙은 동일하게 한 단계 더 밀릴 뿐이다.
5. 요약 — 판단 순서
- 넘기고 싶은 값이 무슨 타입인지 확인한다(
T값 자체인지,T*인지,T**인지). - 그 값을 그대로
void*자리에 넣는다. 포인터가 아니면(T)&로 주소를 떠서 넣는다. - 받는 쪽(콜백) 캐스팅은 넘긴 실제 타입과 정확히 짝을 맞춘다 —
T의 주소를 넘겼으면(T *)arg,T*를 그대로 넘겼으면 결과적으로 같은(T *)arg,T**를 넘겼으면(T **)arg. &를 추가로 붙일지는 “지금 들고 있는 값이 이미 포인터냐”로만 결정한다 — 몇 단계짜리 포인터든 규칙은 동일하게 반복 적용된다.
6. 주의 — 스레드 생명주기와 소유권
ctx를 스택 지역 변수로 두고 그 주소만 넘기면(struct_temp ctx; pthread_create(..., &ctx); 형태), 넘기는 쪽 함수가 스레드보다 먼저 리턴해버릴 때 ctx가 가리키던 스택 메모리가 무효화된 뒤 스레드가 그 주소를 읽는 use-after-scope 버그가 된다. malloc으로 힙에 만들고 소유권(누가 free할지)을 미리 정해두는 게 안전하다 — 값 하나만 넘길 때의 비슷한 함정은 [C] 정수를 void 포인터 인자에 실어 보내기 — intptr_t 캐스팅 관용구에서 더 다뤘다.